| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365 |
- 'use client';
- import { useState, useEffect, useRef, useCallback } from 'react';
- import { fetchApi } from '@/lib/utils/client';
- import useAuth from '@/hooks/useAuth';
- import { DropdownData } from '@/types/response/mypage/dropdown';
- import './donation-modal.scss';
- type CrewMemberInfo = {
- crewMemberID: number;
- nickname: string;
- thumb: string|null;
- channelName: string|null;
- };
- type ActiveCrew = {
- crewSessionID: number;
- title: string;
- crewName: string;
- members: CrewMemberInfo[];
- }|null;
- type SignatureItem = {
- id: number;
- title: string;
- amount: number;
- matchType: number;
- imageUrl: string;
- };
- type SignatureListResponse = {
- list: SignatureItem[];
- total: number;
- hasMore: boolean;
- };
- type Props = {
- channelSID: string;
- onClose: () => void;
- };
- const PER_PAGE = 6;
- export default function DonationModal({ channelSID, onClose }: Props)
- {
- const { member } = useAuth();
- const [amount, setAmount] = useState(1000);
- const [message, setMessage] = useState('');
- const [sendName, setSendName] = useState(member?.name || member?.sid || '');
- const [isAnonymous, setIsAnonymous] = useState(false);
- const [pointBalance, setPointBalance] = useState<number|null>(null);
- const [activeCrew, setActiveCrew] = useState<ActiveCrew>(null);
- const [selectedMember, setSelectedMember] = useState<number|null>(null);
- const [sending, setSending] = useState(false);
- const [done, setDone] = useState(false);
- // 시그니처 이미지 페이징
- const [signatures, setSignatures] = useState<SignatureItem[]>([]);
- const [sigPage, setSigPage] = useState(1);
- const [sigHasMore, setSigHasMore] = useState(true);
- const [sigLoading, setSigLoading] = useState(false);
- const [selectedSigId, setSelectedSigId] = useState<number|null>(null);
- const sigSentinelRef = useRef<HTMLDivElement|null>(null);
- const presetAmounts = [1000, 3000, 5000, 10000, 30000, 50000];
- useEffect(() => {
- fetchApi<ActiveCrew>(`/api/donation/crew/active/${channelSID}`)
- .then(res => {
- if (res.data) {
- setActiveCrew(res.data);
- }
- })
- .catch(() => {});
- }, [channelSID]);
- useEffect(() => {
- fetchApi<DropdownData>('/api/mypage/dropdown', { silent: true })
- .then(res => {
- if (res.data) {
- setPointBalance(res.data.spendableBalance);
- }
- })
- .catch(() => {});
- }, []);
- // 시그니처 이미지 초기 로드 및 페이징
- const loadSignatures = useCallback(async (page: number) => {
- if (sigLoading) {
- return;
- }
- setSigLoading(true);
- try {
- const res = await fetchApi<SignatureListResponse>(
- `/api/donation/signatures/${channelSID}?page=${page}&perPage=${PER_PAGE}`,
- { silent: true }
- );
- if (res.data) {
- setSignatures(prev => page === 1 ? res.data!.list : [...prev, ...res.data!.list]);
- setSigHasMore(res.data.hasMore);
- }
- } catch {
- setSigHasMore(false);
- } finally {
- setSigLoading(false);
- }
- }, [channelSID, sigLoading]);
- useEffect(() => {
- loadSignatures(1);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [channelSID]);
- // IntersectionObserver 무한 스크롤
- useEffect(() => {
- const sentinel = sigSentinelRef.current;
- if (!sentinel || !sigHasMore || sigLoading) {
- return;
- }
- const observer = new IntersectionObserver((entries) => {
- if (entries[0].isIntersecting) {
- const next = sigPage + 1;
- setSigPage(next);
- loadSignatures(next);
- }
- }, { threshold: 0.5 });
- observer.observe(sentinel);
- return () => observer.disconnect();
- }, [sigPage, sigHasMore, sigLoading, loadSignatures]);
- const handleSignatureClick = (sig: SignatureItem) => {
- setSelectedSigId(sig.id);
- setAmount(sig.amount);
- };
- const handleSend = async () => {
- if (amount < 1000) {
- alert('최소 후원 금액은 1,000원입니다.');
- return;
- }
- const finalSendName = isAnonymous ? '익명' : sendName.trim();
- if (!finalSendName) {
- alert('보내는 사람 이름을 입력해 주세요.');
- return;
- }
- setSending(true);
- try {
- const body: Record<string, unknown> = {
- channelSID,
- amount,
- message: message || null,
- sendName: finalSendName
- };
- if (activeCrew && selectedMember) {
- body.crewSessionID = activeCrew.crewSessionID;
- body.crewMemberID = selectedMember;
- }
- const res = await fetchApi('/api/donation/send', {
- method: 'POST',
- body,
- silent: true
- });
- if (!res.success) {
- const msg = res.message ?? '';
- if (/\uC794\uC561/.test(msg) || msg.includes('부족')) {
- alert('POINT가 부족합니다.');
- } else {
- alert(msg || '후원에 실패했습니다.');
- }
- return;
- }
- setDone(true);
- } catch (err: unknown) {
- alert(err instanceof Error ? err.message : '후원에 실패했습니다.');
- } finally {
- setSending(false);
- }
- };
- if (done) {
- return (
- <div className="donation-modal" role="dialog" aria-modal="true" aria-labelledby="donation-modal-title">
- <div className="donation-modal__overlay" onClick={onClose} />
- <div className="donation-modal__box">
- <div className="donation-modal__done">
- <div className="donation-modal__done-icon" aria-hidden="true">🎉</div>
- <p className="donation-modal__done-text">{amount.toLocaleString()}원 후원 완료!</p>
- <button type="button" className="donation-modal__btn donation-modal__btn--primary" onClick={onClose}>닫기</button>
- </div>
- </div>
- </div>
- );
- }
- return (
- <div className="donation-modal" role="dialog" aria-modal="true" aria-labelledby="donation-modal-title">
- <div className="donation-modal__overlay" onClick={onClose} />
- <div className="donation-modal__box">
- <div className="donation-modal__header">
- <h2 id="donation-modal-title" className="donation-modal__title">후원하기</h2>
- <button type="button" className="donation-modal__close" onClick={onClose} aria-label="닫기">×</button>
- </div>
- <div className="donation-modal__body">
- {/* 시그니처 이미지 그리드 (상단, 이미지 있는 것만) */}
- {signatures.length > 0 && (
- <div className="donation-modal__signatures">
- <label className="donation-modal__label">시그니처 선택 (선택 시 금액 자동 입력)</label>
- <div className="donation-modal__signature-grid" role="listbox" aria-label="시그니처 이미지">
- {signatures.map(sig => {
- const isActive = selectedSigId === sig.id;
- return (
- <button
- type="button"
- key={sig.id}
- className={`donation-modal__signature${isActive ? ' donation-modal__signature--active' : ''}`}
- onClick={() => handleSignatureClick(sig)}
- aria-selected={isActive}
- role="option"
- >
- <img src={sig.imageUrl} alt={sig.title} className="donation-modal__signature-img" />
- <span className="donation-modal__signature-amount">{sig.amount.toLocaleString()}원</span>
- </button>
- );
- })}
- {sigHasMore && <div ref={sigSentinelRef} className="donation-modal__signature-sentinel" aria-hidden="true" />}
- {sigLoading && <div className="donation-modal__signature-loading">불러오는 중...</div>}
- </div>
- </div>
- )}
- {/* 크루원 선택 (시그니처 바로 아래) */}
- {activeCrew && activeCrew.members.length > 0 && (
- <div className="donation-modal__crew">
- <label className="donation-modal__crew-label">
- 크루원에게 후원 <span className="donation-modal__crew-tag">{activeCrew.crewName}</span>
- </label>
- <div className="donation-modal__crew-list">
- <button
- type="button"
- className={`donation-modal__crew-item${selectedMember === null ? ' donation-modal__crew-item--active' : ''}`}
- onClick={() => setSelectedMember(null)}
- >
- <div className="donation-modal__crew-thumb donation-modal__crew-thumb--default">채널</div>
- <span>채널 주인</span>
- </button>
- {activeCrew.members.map(m => (
- <button
- type="button"
- key={m.crewMemberID}
- className={`donation-modal__crew-item${selectedMember === m.crewMemberID ? ' donation-modal__crew-item--active' : ''}`}
- onClick={() => setSelectedMember(m.crewMemberID)}
- >
- {m.thumb ? (
- <img src={m.thumb} alt="" className="donation-modal__crew-thumb" />
- ) : (
- <div className="donation-modal__crew-thumb donation-modal__crew-thumb--default">{m.nickname.charAt(0)}</div>
- )}
- <span>{m.nickname}</span>
- </button>
- ))}
- </div>
- </div>
- )}
- {/* 별명 */}
- <div className="donation-modal__field">
- <div className="donation-modal__label-row">
- <label htmlFor="donation-sendname">별명</label>
- <label className="donation-modal__anon-toggle">
- <input
- type="checkbox"
- checked={isAnonymous}
- onChange={e => setIsAnonymous(e.target.checked)}
- />
- <span>익명</span>
- </label>
- </div>
- <input
- id="donation-sendname"
- type="text"
- value={isAnonymous ? '익명' : sendName}
- onChange={e => setSendName(e.target.value)}
- placeholder="보내는 사람"
- maxLength={20}
- disabled={isAnonymous}
- />
- </div>
- {/* 금액 */}
- <div className="donation-modal__field">
- <div className="donation-modal__label-row">
- <label htmlFor="donation-amount">후원 금액</label>
- {pointBalance !== null && (
- <span className="donation-modal__balance" aria-live="polite">
- 잔액 {pointBalance.toLocaleString()}P
- </span>
- )}
- </div>
- <input
- id="donation-amount"
- type="number"
- min={1000}
- max={10000000}
- step={1000}
- value={amount}
- onChange={e => { setAmount(Number(e.target.value)); setSelectedSigId(null); }}
- />
- <div className="donation-modal__presets" role="group" aria-label="금액 프리셋">
- {presetAmounts.map(a => (
- <button
- type="button"
- key={a}
- className={`donation-modal__preset${amount === a ? ' donation-modal__preset--active' : ''}`}
- onClick={() => { setAmount(a); setSelectedSigId(null); }}
- >
- {a.toLocaleString()}원
- </button>
- ))}
- </div>
- </div>
- {/* 메시지 */}
- <div className="donation-modal__field">
- <div className="donation-modal__label-row">
- <label htmlFor="donation-message">메시지 (선택)</label>
- <span className={`donation-modal__msg-counter${message.length >= 100 ? ' donation-modal__msg-counter--max' : ''}`} aria-live="polite">
- {message.length}/100
- </span>
- </div>
- <textarea
- id="donation-message"
- value={message}
- onChange={e => setMessage(e.target.value)}
- placeholder="응원 메시지를 남겨주세요"
- maxLength={100}
- rows={2}
- />
- </div>
- </div>
- {/* 푸터: 취소 / 보내기 */}
- <div className="donation-modal__footer">
- <button type="button" className="donation-modal__btn" onClick={onClose}>취소</button>
- <button
- type="button"
- className="donation-modal__btn donation-modal__btn--primary"
- onClick={handleSend}
- disabled={sending}
- >
- {sending ? '전송 중...' : '보내기'}
- </button>
- </div>
- </div>
- </div>
- );
- }
|